Very fun game but sonion th onion

This sketch is a complete arcade-style game where you steer a layered onion character named Sonion around the screen to collect ten glowing 'flavor pearls' before a 30-second timer runs out, all while dodging falling rotten vegetables that cost you lives. It uses a finite state machine to switch between start, playing, game-over and win screens, and supports both keyboard (WASD/arrows) and touch/mouse controls.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up Sonion — Increasing sonionSpeed makes Sonion dash across the screen much faster with keyboard controls, making dodging easier.
  2. Give yourself more time — Raising GAME_DURATION extends the countdown so you have longer to gather all ten pearls.
  3. Make veggies more dangerous — Raising VEGGIE_SPEED_MAX means some vegetables will fall much faster, making the game noticeably harder.
  4. Change the background mood — Swapping the background color changes the whole game's atmosphere from a bright kitchen floor to something darker or moodier.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch turns p5.js into a tiny arcade game: you guide 'Sonion', a hand-drawn layered onion, around the canvas to scoop up ten glowing flavor pearls before a 30-second countdown expires, all while bouncing rotten vegetables fall from the top of the screen and cost you a life on contact. It leans on several core p5.js and JavaScript techniques - a finite state machine for START/PLAYING/GAMEOVER/WIN screens, ES6 classes for the player, pearls and vegetables, circle-based collision detection with dist(), and dual input handling through keyPressed()/keyReleased() plus touchStarted()/touchMoved()/touchEnded().

The code is organized around three classes (Sonion, FlavorPearl, RottenVegetable) that each know how to draw() and update() themselves, a resetGame() function that builds a fresh batch of pearls and veggies, and a draw() loop that simply asks 'what state are we in?' and delegates to the right screen-drawing function. By studying it you'll learn how to structure a small game without a game engine: how to track elapsed time with millis(), how to fake physics-y bouncing collectibles, and how to make one codebase respond to both keyboard and touchscreen input.

⚙️ How It Works

  1. When the page loads, setup() creates a full-window canvas and calls resetGame(), which places Sonion near the bottom, resets the score and lives, and scatters 10 flavor pearls and 5 falling rotten veggies across the top half of the screen while the game state is set to START.
  2. On the very first frame the game just shows the start screen (a translucent overlay with instructions) and waits for a click or tap, since draw() only calls updateGame() when gameState equals PLAYING.
  3. Once you tap/click to start, every frame draw() clears the background, moves Sonion based on whichever WASD/arrow keys are held (or towards the last touch point), moves each rotten veggie downward and rotates it, and checks circle-based collisions between Sonion and every veggie and pearl using the dist() function.
  4. Colliding with a veggie decrements sonionLives and instantly resets that veggie back to the top of the screen with a new random shape and speed; colliding with an uncollected pearl marks it collected and increments the score.
  5. In the background, updateGame() also recalculates remainingTime from millis() every frame; if time runs out before all 10 pearls are collected, or lives hit zero, the game flips to GAMEOVER, and if the score reaches NUM_PEARLS first, it flips to WIN - both screens invite another tap or click to call resetGame() again.

🎓 Concepts You'll Learn

Finite state machine (START/PLAYING/GAMEOVER/WIN)ES6 classes for game objectsCircle-circle collision detection with dist()Timing with millis() for a countdownKeyboard input via keyPressed()/keyReleased() flagsTouch input via touchStarted()/touchMoved()/touchEnded()Responsive canvas with windowResized()

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it's kept tiny on purpose - all the real initialization logic lives in resetGame() so it can be reused later when the player restarts.

function setup() {
  createCanvas(windowWidth, windowHeight);
  resetGame(); // Initialize game objects
}
Line-by-line explanation (2 lines)
createCanvas(windowWidth, windowHeight)
Creates a canvas that fills the entire browser window, so the game is playable at any screen size.
resetGame()
Immediately builds the player, pearls and veggies and sets the game state to START, so the sketch is ready the moment it loads.

resetGame()

resetGame() is the single source of truth for 'what a fresh game looks like'. Reusing it both in setup() and whenever the player restarts avoids duplicating initialization logic - a common and useful pattern in small games.

🔬 This loop confines pearls to the top half of the screen with height / 2. What happens if you change that to height (the full canvas), or height / 4 (a tighter cluster near the top)?

  for (let i = 0; i < NUM_PEARLS; i++) {
    flavorPearls.push(new FlavorPearl(random(PEARL_RADIUS, width - PEARL_RADIUS), random(PEARL_RADIUS, height / 2)));
  }
function resetGame() {
  sonion = new Sonion(width / 2, height - SONION_RADIUS * 2);
  sonionLives = 3;
  score = 0;
  flavorPearls = [];
  rottenVeggies = [];
  startTime = millis(); // Start the game timer
  touchTargetX = -1;
  touchTargetY = -1;

  // Create flavor pearls
  for (let i = 0; i < NUM_PEARLS; i++) {
    flavorPearls.push(new FlavorPearl(random(PEARL_RADIUS, width - PEARL_RADIUS), random(PEARL_RADIUS, height / 2)));
  }

  // Create rotten vegetables
  for (let i = 0; i < NUM_VEGGIES; i++) {
    rottenVeggies.push(new RottenVegetable(random(VEGGIE_SIZE / 2, width - VEGGIE_SIZE / 2), random(-height, 0), random(['square', 'triangle', 'circle'])));
  }

  gameState = START;
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Spawn Flavor Pearls for (let i = 0; i < NUM_PEARLS; i++) { flavorPearls.push(new FlavorPearl(...)); }

Creates NUM_PEARLS pearl objects at random positions in the top half of the screen.

for-loop Spawn Rotten Veggies for (let i = 0; i < NUM_VEGGIES; i++) { rottenVeggies.push(new RottenVegetable(...)); }

Creates NUM_VEGGIES vegetable objects above the screen with a random shape type, ready to fall.

sonion = new Sonion(width / 2, height - SONION_RADIUS * 2);
Creates a brand-new Sonion object near the bottom-center of the screen every time the game (re)starts.
flavorPearls = [];
Empties the pearls array so old, already-collected pearls don't carry over into a new game.
startTime = millis();
Records the current time in milliseconds since the sketch started - this is the baseline the countdown timer will subtract from.
flavorPearls.push(new FlavorPearl(random(PEARL_RADIUS, width - PEARL_RADIUS), random(PEARL_RADIUS, height / 2)));
Adds one new pearl at a random x/y position, kept in the top half of the canvas (height / 2) so pearls don't spawn on top of Sonion.
rottenVeggies.push(new RottenVegetable(random(VEGGIE_SIZE / 2, width - VEGGIE_SIZE / 2), random(-height, 0), random(['square', 'triangle', 'circle'])));
Adds one veggie above the visible canvas (negative y) with a random horizontal position and a randomly chosen shape type from the array.
gameState = START;
Puts the game back into the START state so the player sees the instructions screen before playing again.

draw()

draw() is p5.js's built-in animation loop, running about 60 times per second. Keeping it short and delegating to named helper functions like updateGame() and drawGame() is a great habit for readable game code.

🔬 This if/else chain is the entire game state machine. What do you think would happen if you swapped the order so GAMEOVER is checked before PLAYING? (Hint: it wouldn't break anything here since gameState can only equal one value at a time - but it shows why state machines rely on mutually exclusive states.)

  if (gameState === START) {
    drawStartScreen();
  } else if (gameState === PLAYING) {
    updateGame();
    drawGame();
  } else if (gameState === GAMEOVER) {
    drawGameOverScreen();
  } else if (gameState === WIN) {
    drawWinScreen();
  }
function draw() {
  background(200, 255, 200); // Light green background (garden/kitchen floor)

  if (gameState === START) {
    drawStartScreen();
  } else if (gameState === PLAYING) {
    updateGame();
    drawGame();
  } else if (gameState === GAMEOVER) {
    drawGameOverScreen();
  } else if (gameState === WIN) {
    drawWinScreen();
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Game State Router if (gameState === START) { ... } else if (gameState === PLAYING) { ... }

Decides which screen-drawing function to call based on the current gameState, acting as the game's state machine.

background(200, 255, 200);
Repaints the whole canvas with a light green color every frame, erasing the previous frame's drawing before new shapes are drawn - this is what makes the animation look smooth instead of leaving trails.
if (gameState === START) {
Checks which of the four possible states the game is currently in.
updateGame();
Only runs the movement, collision and timer logic while the game is actively being played, not on the start/end screens.
drawGame();
Draws the pearls, veggies, Sonion and HUD text - called right after updating positions so everything appears in its new spot.

updateGame()

This function is the game's 'brain' each frame: it moves things, checks every meaningful collision, and updates the clock. Separating this logic from drawing (in drawGame()) keeps the code easier to reason about.

🔬 This loop resets a veggie the instant it hits Sonion. What happens if you remove the rottenVeggies[i].reset(); line - would the game get harder or just visually glitchy?

  for (let i = rottenVeggies.length - 1; i >= 0; i--) {
    rottenVeggies[i].move();
    if (sonion.checkCollision(rottenVeggies[i])) {
      sonionLives--;
      rottenVeggies[i].reset(); // Reset veggie after collision
      if (sonionLives <= 0) {
        gameState = GAMEOVER;
      }
    }
  }
function updateGame() {
  // Update Sonion's movement
  sonion.move();

  // Update rotten vegetables and check for collision
  for (let i = rottenVeggies.length - 1; i >= 0; i--) {
    rottenVeggies[i].move();
    if (sonion.checkCollision(rottenVeggies[i])) {
      sonionLives--;
      rottenVeggies[i].reset(); // Reset veggie after collision
      if (sonionLives <= 0) {
        gameState = GAMEOVER;
      }
    }
  }

  // Check for flavor pearl collection
  for (let i = flavorPearls.length - 1; i >= 0; i--) {
    if (!flavorPearls[i].collected && sonion.checkCollision(flavorPearls[i])) {
      flavorPearls[i].collected = true;
      score++;
      if (score >= NUM_PEARLS) {
        gameState = WIN; // Win condition
      }
    }
  }

  // Update game timer
  let elapsedTime = (millis() - startTime) / 1000;
  remainingTime = max(0, GAME_DURATION - elapsedTime);
  if (remainingTime <= 0 && score < NUM_PEARLS) {
    gameState = GAMEOVER; // Time ran out, not enough pearls
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop Veggie Movement & Collision for (let i = rottenVeggies.length - 1; i >= 0; i--) { ... }

Moves every rotten vegetable and checks whether it's touching Sonion, deducting a life on impact.

for-loop Pearl Collection Check for (let i = flavorPearls.length - 1; i >= 0; i--) { ... }

Checks each uncollected pearl for a collision with Sonion and marks it collected, incrementing the score.

calculation Countdown Timer let elapsedTime = (millis() - startTime) / 1000;

Converts milliseconds since the game started into seconds elapsed, then subtracts from GAME_DURATION to get time remaining.

sonion.move();
Calls the Sonion object's own move() method, letting the class handle its own keyboard/touch logic.
for (let i = rottenVeggies.length - 1; i >= 0; i--) {
Loops backwards through the veggies array - a common p5.js pattern that's safe even if items were removed or reset during the loop.
if (sonion.checkCollision(rottenVeggies[i])) {
Uses the Sonion class's checkCollision() helper to see if the two circles (Sonion and this veggie) are overlapping.
sonionLives--;
Subtracts one life whenever Sonion touches a rotten vegetable.
rottenVeggies[i].reset();
Immediately teleports the veggie back above the screen with a new random shape/speed so it doesn't keep hitting Sonion every frame.
if (!flavorPearls[i].collected && sonion.checkCollision(flavorPearls[i])) {
Only checks pearls that haven't already been collected, then tests for a collision with Sonion.
let elapsedTime = (millis() - startTime) / 1000;
millis() returns milliseconds since the sketch launched; subtracting startTime gives milliseconds since this round began, and dividing by 1000 converts to seconds.
remainingTime = max(0, GAME_DURATION - elapsedTime);
Subtracts elapsed time from the total allowed duration, using max() to make sure the displayed time never goes negative.

drawGame()

drawGame() only handles visuals - it never changes game state or positions. Splitting 'update' logic (updateGame) from 'draw' logic (drawGame) is a pattern that scales well as games get more complex.

function drawGame() {
  // Draw flavor pearls
  for (let pearl of flavorPearls) {
    pearl.draw();
  }

  // Draw rotten vegetables
  for (let veggie of rottenVeggies) {
    veggie.draw();
  }

  // Draw Sonion
  sonion.draw();

  // Draw Score and Lives
  fill(0);
  textSize(24);
  textAlign(LEFT, TOP);
  text(`Score: ${score}`, 10, 10);
  text(`Lives: ${sonionLives}`, 10, 40);
  text(`Time: ${ceil(remainingTime)}s`, 10, 70);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Draw Every Pearl for (let pearl of flavorPearls) { pearl.draw(); }

Calls each pearl's own draw() method (which internally skips drawing if collected).

for-loop Draw Every Veggie for (let veggie of rottenVeggies) { veggie.draw(); }

Calls each vegetable's own draw() method to render its shape and rot spots.

for (let pearl of flavorPearls) {
Uses a for...of loop to visit every pearl object in the array without needing an index variable.
sonion.draw();
Draws Sonion last so the onion visually appears on top of pearls and veggies.
text(`Score: ${score}`, 10, 10);
Uses a JavaScript template literal to embed the current score variable directly inside the displayed string.
text(`Time: ${ceil(remainingTime)}s`, 10, 70);
ceil() rounds the remaining time up to the next whole second so the countdown displays as clean integers like '12s' instead of '11.7s'.

drawStartScreen()

This function draws a static instructional overlay while gameState is START. It never runs updateGame(), so nothing moves until the player taps or clicks.

function drawStartScreen() {
  fill(0, 150);
  rect(0, 0, width, height);

  textAlign(CENTER, CENTER);
  fill(255);
  textSize(48);
  text("the Onion's Adventure!", width / 2, height / 2 - 80);
  textSize(24);
  text("Collect all flavor pearls, avoid rotten veggies!", width / 2, height / 2 - 30);
  text("Use WASD/Arrows (desktop) or Tap/Touch (mobile) to move.", width / 2, height / 2 + 10);
  text("Tap or Click to Start", width / 2, height / 2 + 80);
}
Line-by-line explanation (3 lines)
fill(0, 150);
Sets a semi-transparent black fill (alpha 150 out of 255) that will darken the background behind the text.
rect(0, 0, width, height);
Draws that translucent rectangle over the entire canvas, creating a dimmed overlay effect.
textAlign(CENTER, CENTER);
Makes all following text() calls center themselves horizontally and vertically around the given x/y coordinate, simplifying centering math.

drawGameOverScreen()

This screen appears whenever gameState becomes GAMEOVER (either lives reach 0 or the timer runs out without collecting all pearls). It reuses the same tap/click-to-restart pattern as the start screen.

function drawGameOverScreen() {
  fill(150, 0, 0, 150); // Dark red overlay
  rect(0, 0, width, height);

  textAlign(CENTER, CENTER);
  fill(255);
  textSize(64);
  text("GAME OVER!", width / 2, height / 2 - 80);
  textSize(32);
  text(`You collected ${score} flavor pearls.`, width / 2, height / 2 - 20);
  textSize(24);
  text("Tap or Click to Play Again", width / 2, height / 2 + 80);
}
Line-by-line explanation (2 lines)
fill(150, 0, 0, 150); // Dark red overlay
Uses an RGBA fill with a red tint and alpha of 150, giving the game-over overlay a bloody, urgent color that's different from the start screen's neutral black.
text(`You collected ${score} flavor pearls.`, width / 2, height / 2 - 20);
Displays the final score using a template literal so the number updates automatically based on the score variable.

drawWinScreen()

This screen only appears when score reaches NUM_PEARLS inside updateGame(), demonstrating how a simple counter comparison can trigger a whole new game state.

function drawWinScreen() {
  fill(0, 150, 0, 150); // Dark green overlay
  rect(0, 0, width, height);

  textAlign(CENTER, CENTER);
  fill(255);
  textSize(64);
  text("YOU WIN!", width / 2, height / 2 - 80);
  textSize(32);
  text(`Congratulations! You collected all ${score} flavor pearls!`, width / 2, height / 2 - 20);
  textSize(24);
  text("Tap or Click to Play Again", width / 2, height / 2 + 80);
}
Line-by-line explanation (2 lines)
fill(0, 150, 0, 150); // Dark green overlay
A translucent green overlay visually signals success, contrasting with the red 'game over' overlay.
text("YOU WIN!", width / 2, height / 2 - 80);
Displays a large celebratory headline centered on screen.

keyPressed()

p5.js automatically calls keyPressed() once every time any key goes down. Rather than moving Sonion directly here, this function just flips boolean flags that Sonion.move() checks every frame - this avoids jerky single-press movement and allows smooth continuous motion while a key is held.

🔬 Each line checks two possible keys with ||. What would you add here to also let the player move with 'i', 'k', 'j', 'l'?

    if (key === 'w' || keyCode === UP_ARROW) keyWPressed = true;
    if (key === 's' || keyCode === DOWN_ARROW) keySPressed = true;
    if (key === 'a' || keyCode === LEFT_ARROW) keyAPressed = true;
    if (key === 'd' || keyCode === RIGHT_ARROW) keyDPressed = true;
function keyPressed() {
  if (gameState === PLAYING) {
    if (key === 'w' || keyCode === UP_ARROW) keyWPressed = true;
    if (key === 's' || keyCode === DOWN_ARROW) keySPressed = true;
    if (key === 'a' || keyCode === LEFT_ARROW) keyAPressed = true;
    if (key === 'd' || keyCode === RIGHT_ARROW) keyDPressed = true;
  }
  return false; // Prevent default browser behavior
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Set Movement Flags if (key === 'w' || keyCode === UP_ARROW) keyWPressed = true;

Turns on a boolean flag when either the letter key or the equivalent arrow key is pressed, supporting both control schemes at once.

if (gameState === PLAYING) {
Only registers movement key presses while the game is actually being played, ignoring keys on the start/end screens.
if (key === 'w' || keyCode === UP_ARROW) keyWPressed = true;
Sets keyWPressed to true if either the 'w' key or the up arrow key was pressed - this flag is later read inside Sonion's move() method.
return false; // Prevent default browser behavior
Stops the browser's default behavior for that key (like scrolling the page when arrow keys are pressed).

keyReleased()

This mirrors keyPressed() exactly but sets flags to false instead of true. Together, keyPressed()/keyReleased() implement smooth 'hold to move' controls instead of jumpy 'press to nudge' controls.

function keyReleased() {
  if (gameState === PLAYING) {
    if (key === 'w' || keyCode === UP_ARROW) keyWPressed = false;
    if (key === 's' || keyCode === DOWN_ARROW) keySPressed = false;
    if (key === 'a' || keyCode === LEFT_ARROW) keyAPressed = false;
    if (key === 'd' || keyCode === RIGHT_ARROW) keyDPressed = false;
  }
  return false; // Prevent default browser behavior
}
Line-by-line explanation (1 lines)
if (key === 'w' || keyCode === UP_ARROW) keyWPressed = false;
Turns the movement flag back off the moment the key is lifted, stopping Sonion from moving in that direction.

touchStarted()

touchStarted() runs whenever the screen is tapped or the mouse is clicked. Combining it with the gameState check lets one function do double duty - restarting the game and setting a movement target - depending on what's currently happening.

function touchStarted() {
  if (gameState === START || gameState === GAMEOVER || gameState === WIN) {
    resetGame();
    gameState = PLAYING;
  } else if (gameState === PLAYING) {
    // For touch, set a target for Sonion to move towards
    touchTargetX = touches.length > 0 ? touches[0].x : mouseX;
    touchTargetY = touches.length > 0 ? touches[0].y : mouseY;
  }
  return false; // Prevent default browser behavior (scrolling/zooming)
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Start or Restart the Game if (gameState === START || gameState === GAMEOVER || gameState === WIN) { resetGame(); gameState = PLAYING; }

Lets a single tap on any non-playing screen immediately restart the game.

if (gameState === START || gameState === GAMEOVER || gameState === WIN) {
Checks if the player is on any of the three 'non-playing' screens, since a tap should restart the game from any of them.
touchTargetX = touches.length > 0 ? touches[0].x : mouseX;
Uses a ternary operator: if there's at least one active touch point, use its x coordinate; otherwise fall back to mouseX so the same code works with mouse clicks on desktop.

touchMoved()

This function fires continuously while a finger is dragging, keeping the touch target in sync with the player's finger so Sonion.move() can chase it every frame.

function touchMoved() {
  if (gameState === PLAYING) {
    // Update target as finger moves
    touchTargetX = touches.length > 0 ? touches[0].x : mouseX;
    touchTargetY = touches.length > 0 ? touches[0].y : mouseY;
  }
  return false; // Prevent default browser behavior
}
Line-by-line explanation (1 lines)
touchTargetX = touches.length > 0 ? touches[0].x : mouseX;
Continuously updates the target Sonion is walking towards as the finger drags across the screen, creating drag-to-move controls.

touchEnded()

Using -1 as a special 'no target' value is a simple sentinel pattern - it avoids needing a separate boolean flag to track whether touch movement is active.

function touchEnded() {
  if (gameState === PLAYING) {
    touchTargetX = -1; // Stop movement when touch is released
    touchTargetY = -1;
  }
  return false; // Prevent default browser behavior
}
Line-by-line explanation (1 lines)
touchTargetX = -1; // Stop movement when touch is released
Resets the target to a sentinel value of -1, which Sonion.move() treats as 'no active touch target', so Sonion stops moving when the finger lifts.

windowResized()

windowResized() is a special p5.js function automatically called whenever the browser window changes size. Keeping the canvas full-window makes the game feel native on both desktop and mobile.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  // Re-center Sonion and potentially re-distribute other elements for new size
  if (sonion) {
    sonion.x = width / 2;
    sonion.y = height - SONION_RADIUS * 2;
  }
  // For a more robust game, you might want to re-initialize flavorPearls and rottenVeggies here too.
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the browser window whenever it's resized or rotated (important for mobile devices).
if (sonion) {
Checks that the sonion object actually exists yet, avoiding an error if the window is resized before setup() has finished.

Sonion.draw()

This method builds a whole character from layered ellipses, a ring of lines using trigonometry, and simple facial features - a great example of composing a complex drawing entirely out of basic shapes.

🔬 This loop draws 6 texture lines around Sonion. What happens visually if you change the loop limit from 6 to 20 - and update the map() call to match?

    for (let i = 0; i < 6; i++) {
      let angle = map(i, 0, 6, 0, TWO_PI);
      let x1 = this.x + cos(angle) * this.radius * 0.5;
      let y1 = this.y + sin(angle) * this.radius * 0.7;
      let x2 = this.x + cos(angle) * this.radius * 1.1;
      let y2 = this.y + sin(angle) * this.radius * 1.3;
      line(x1, y1, x2, y2);
    }
  draw() {
    noStroke();
    
    // Outer layers (light green/yellow)
    fill(200, 255, 200, 200); // Light green-yellow, slightly transparent
    ellipse(this.x, this.y, this.radius * 2.2, this.radius * 2.8);
    fill(220, 255, 220, 200); // Lighter layer
    ellipse(this.x, this.y, this.radius * 1.8, this.radius * 2.4);
    
    // Core (white/yellow)
    fill(255, 255, 220); // Creamy white
    ellipse(this.x, this.y, this.radius * 1.5, this.radius * 2);
    
    // Root/Stem base (light brown)
    fill(180, 160, 140);
    ellipse(this.x, this.y + this.radius * 0.8, this.radius * 0.8, this.radius * 0.4);
    
    // Add some texture lines
    stroke(150, 200, 150, 100); // Faint green lines
    strokeWeight(1);
    for (let i = 0; i < 6; i++) {
      let angle = map(i, 0, 6, 0, TWO_PI);
      let x1 = this.x + cos(angle) * this.radius * 0.5;
      let y1 = this.y + sin(angle) * this.radius * 0.7;
      let x2 = this.x + cos(angle) * this.radius * 1.1;
      let y2 = this.y + sin(angle) * this.radius * 1.3;
      line(x1, y1, x2, y2);
    }
    
    noStroke();
    // Eyes (simple white dots)
    fill(255);
    ellipse(this.x - this.radius * 0.3, this.y - this.radius * 0.4, this.radius * 0.25);
    ellipse(this.x + this.radius * 0.3, this.y - this.radius * 0.4, this.radius * 0.25);
    
    // Pupils
    fill(0);
    ellipse(this.x - this.radius * 0.3, this.y - this.radius * 0.4, this.radius * 0.1);
    ellipse(this.x + this.radius * 0.3, this.y - this.radius * 0.4, this.radius * 0.1);
    
    // Mouth (simple curve)
    noFill();
    stroke(0);
    strokeWeight(2);
    arc(this.x, this.y - this.radius * 0.1, this.radius * 0.4, this.radius * 0.2, 0, PI);
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Onion Layer Texture Lines for (let i = 0; i < 6; i++) { ... line(x1, y1, x2, y2); }

Draws 6 short lines evenly spaced around the onion using trigonometry (cos/sin) to fake the look of onion-skin ridges.

fill(200, 255, 200, 200); // Light green-yellow, slightly transparent
Sets a semi-transparent fill color (alpha 200 of 255) for the outermost onion layer, letting layers beneath show through slightly.
ellipse(this.x, this.y, this.radius * 2.2, this.radius * 2.8);
Draws an ellipse wider on one axis than the other (2.2x vs 2.8x the radius) to give the onion a tall, layered look rather than a perfect circle.
let angle = map(i, 0, 6, 0, TWO_PI);
Converts the loop counter (0 to 5) into an angle spread evenly around a full circle (0 to TWO_PI radians).
let x1 = this.x + cos(angle) * this.radius * 0.5;
Uses cos(angle) to compute the x-offset for a point sitting on a circle around Sonion's center, scaled to half the onion's radius.
arc(this.x, this.y - this.radius * 0.1, this.radius * 0.4, this.radius * 0.2, 0, PI);
Draws only the bottom half of an ellipse (from angle 0 to PI) to create a simple smiling mouth curve.

Sonion.move()

This method elegantly handles two totally different input styles - direct key-based movement and normalized vector 'chase the target' movement for touch - inside one function, switching between them based on which input was used most recently.

🔬 TOUCH_THRESHOLD decides how close is 'close enough' to stop chasing the touch point. What happens if you set the threshold very large, like 200, instead of the current small value?

      if (distance > TOUCH_THRESHOLD) {
        this.x += (dx / distance) * TOUCH_MOVE_SPEED;
        this.y += (dy / distance) * TOUCH_MOVE_SPEED;
      } else {
        // Reached target, stop touch movement
        touchTargetX = -1;
        touchTargetY = -1;
      }
  move() {
    if (keyWPressed || keyAPressed || keySPressed || keyDPressed) {
      if (keyWPressed) this.y -= sonionSpeed;
      if (keySPressed) this.y += sonionSpeed;
      if (keyAPressed) this.x -= sonionSpeed;
      if (keyDPressed) this.x += sonionSpeed;
      touchTargetX = -1; // Cancel touch movement if keys are pressed
      touchTargetY = -1;
    } else if (touchTargetX !== -1 && touchTargetY !== -1) {
      // Move towards touch target
      let dx = touchTargetX - this.x;
      let dy = touchTargetY - this.y;
      let distance = dist(this.x, this.y, touchTargetX, touchTargetY);

      if (distance > TOUCH_THRESHOLD) {
        this.x += (dx / distance) * TOUCH_MOVE_SPEED;
        this.y += (dy / distance) * TOUCH_MOVE_SPEED;
      } else {
        // Reached target, stop touch movement
        touchTargetX = -1;
        touchTargetY = -1;
      }
    }

    // Keep Sonion within canvas bounds
    this.x = constrain(this.x, this.radius, width - this.radius);
    this.y = constrain(this.y, this.radius, height - this.radius);
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Keyboard Movement if (keyWPressed || keyAPressed || keySPressed || keyDPressed) { ... }

Moves Sonion based on which WASD/arrow flags are currently true, and cancels any pending touch movement.

conditional Touch-Chase Movement else if (touchTargetX !== -1 && touchTargetY !== -1) { ... }

Moves Sonion smoothly towards the last tapped/dragged screen point using vector normalization.

if (keyWPressed) this.y -= sonionSpeed;
Decreasing y moves Sonion upward on screen (since y increases downward in p5.js's coordinate system).
let dx = touchTargetX - this.x;
Calculates the horizontal distance from Sonion to the touch target.
let distance = dist(this.x, this.y, touchTargetX, touchTargetY);
Uses p5's dist() function to get the straight-line distance to the target, needed to normalize the direction vector.
this.x += (dx / distance) * TOUCH_MOVE_SPEED;
Divides dx by distance to get a direction value between -1 and 1, then multiplies by the desired speed - this ensures Sonion always moves at a constant speed regardless of how far away the target is.
this.x = constrain(this.x, this.radius, width - this.radius);
Uses constrain() to clamp Sonion's x position so it never goes further than its own radius past the canvas edges, keeping the whole onion visible.

Sonion.checkCollision()

This tiny reusable method is called for both pearls and veggies, showing how a single well-written function (circle-circle collision) can power very different game mechanics - collecting items and taking damage.

🔬 This is the entire collision-detection formula for the whole game. What would happen gameplay-wise if you made it more forgiving by requiring d < (this.radius + other.radius) * 0.5 instead?

  checkCollision(other) {
    let d = dist(this.x, this.y, other.x, other.y);
    return d < (this.radius + other.radius);
  }
  checkCollision(other) {
    let d = dist(this.x, this.y, other.x, other.y);
    return d < (this.radius + other.radius);
  }
Line-by-line explanation (2 lines)
let d = dist(this.x, this.y, other.x, other.y);
Calculates the straight-line distance between Sonion's center and the other object's center.
return d < (this.radius + other.radius);
Two circles overlap exactly when the distance between their centers is less than the sum of their radii - this is the classic circle-circle collision formula.

FlavorPearl.draw()

Layering three semi-transparent ellipses of different sizes and opacities is a simple but effective technique for making a flat 2D circle read as a glowing, glossy gem.

  draw() {
    if (!this.collected) {
      noStroke();
      fill(255, 255, 100, 150); // Light yellow, semi-transparent
      ellipse(this.x, this.y, this.radius * 2);
      fill(255, 255, 200, 200); // Lighter highlight
      ellipse(this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.8);
      fill(255, 255, 100, 255); // More opaque yellow core
      ellipse(this.x, this.y, this.radius * 1.5);
    }
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Skip Drawing If Collected if (!this.collected) { ... }

Prevents an already-collected pearl from being drawn at all, making it visually disappear once collected.

if (!this.collected) {
Only runs the drawing code when this.collected is false - once a pearl is picked up, this whole block is skipped, so nothing is drawn.
fill(255, 255, 100, 150); // Light yellow, semi-transparent
Draws a soft, glowing outer ellipse using a low alpha value for a translucent halo effect.
fill(255, 255, 200, 200); // Lighter highlight
Adds a smaller, offset lighter ellipse to simulate a shiny highlight, giving the pearl a glossy 3D look.

RottenVegetable.draw()

This method shows push()/translate()/rotate()/pop() in action - the standard p5.js pattern for rotating a shape around its own center without affecting the rest of the sketch's coordinate system.

🔬 Only squares, triangles and circles exist. What would you need to add here to support a fourth shape type, like a 'star'?

    if (this.type === 'square') {
      rectMode(CENTER);
      rect(this.x, this.y, this.size);
    } else if (this.type === 'triangle') {
      push();
      translate(this.x, this.y);
      rotate(this.angle);
      triangle(-this.size / 2, this.size / 2, this.size / 2, this.size / 2, 0, -this.size / 2);
      pop();
    } else if (this.type === 'circle') {
      ellipse(this.x, this.y, this.size);
    }
  draw() {
    noStroke();
    fill(100, 80, 60); // Dark brown/green for rotten look
    
    // Add some random bumps/irregularity
    for (let i = 0; i < 3; i++) {
      let bumpX = this.x + random(-this.size * 0.2, this.size * 0.2);
      let bumpY = this.y + random(-this.size * 0.2, this.size * 0.2);
      let bumpSize = random(this.size * 0.3, this.size * 0.6);
      ellipse(bumpX, bumpY, bumpSize);
    }

    // Draw the main shape
    fill(150, 120, 100); // Slightly lighter brown
    if (this.type === 'square') {
      rectMode(CENTER);
      rect(this.x, this.y, this.size);
    } else if (this.type === 'triangle') {
      push();
      translate(this.x, this.y);
      rotate(this.angle);
      triangle(-this.size / 2, this.size / 2, this.size / 2, this.size / 2, 0, -this.size / 2);
      pop();
    } else if (this.type === 'circle') {
      ellipse(this.x, this.y, this.size);
    }
    
    // Add some dark spots for rot
    fill(50, 40, 30, 200);
    ellipse(this.x + random(-this.size * 0.2, this.size * 0.2), this.y + random(-this.size * 0.2, this.size * 0.2), this.size * 0.3);
    ellipse(this.x + random(-this.size * 0.2, this.size * 0.2), this.y + random(-this.size * 0.2, this.size * 0.2), this.size * 0.2);
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Random Bump Texture for (let i = 0; i < 3; i++) { ... ellipse(bumpX, bumpY, bumpSize); }

Draws 3 randomly-placed, randomly-sized ellipses behind the main shape to fake an irregular, lumpy rotten texture.

conditional Shape Type Branch if (this.type === 'square') { ... } else if (this.type === 'triangle') { ... } else if (this.type === 'circle') { ... }

Draws a different primitive shape depending on which type string this veggie was randomly assigned.

let bumpX = this.x + random(-this.size * 0.2, this.size * 0.2);
Picks a random offset within 20% of the veggie's size in either direction, scattering bumps around its center.
push();
Saves the current drawing state (position, rotation) so the upcoming translate/rotate only affects this triangle, not anything drawn afterward.
translate(this.x, this.y);
Moves the coordinate system's origin to the veggie's position, so the triangle can be drawn and rotated around its own center rather than the canvas corner.
rotate(this.angle);
Rotates the coordinate system by the veggie's current angle, making the triangle spin as this.angle increases each frame in move().
pop();
Restores the saved drawing state, undoing the translate/rotate so later shapes (like the win/gameover overlays) aren't accidentally rotated too.

RottenVegetable.move()

This tiny method demonstrates the classic 'wrap-around' pattern used in endless scrollers: instead of destroying and recreating objects, simply reset one that has left the screen so it can be reused indefinitely.

  move() {
    this.y += this.speed;
    this.angle += 0.05; // Rotate triangles

    if (this.y > height + this.size / 2) {
      this.reset();
    }
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Off-Screen Reset Check if (this.y > height + this.size / 2) { this.reset(); }

Detects when a veggie has fully fallen past the bottom edge and sends it back to the top.

this.y += this.speed;
Moves the veggie downward every frame by its own individual speed value, set randomly when it was created.
this.angle += 0.05; // Rotate triangles
Slowly increases the rotation angle each frame, which the triangle shape uses in draw() to spin continuously - squares and circles ignore this since they don't use rotate().
if (this.y > height + this.size / 2) {
Checks if the veggie has moved completely below the visible canvas (adding half its size ensures it's fully off-screen, not just its center).

RottenVegetable.reset()

This method is called both when a veggie falls off the bottom of the screen and when it collides with Sonion, meaning it doubles as both a 'recycle' function and a 'punishment reset' after a hit.

  reset() {
    this.x = random(this.size / 2, width - this.size / 2);
    this.y = -this.size / 2;
    this.speed = random(VEGGIE_SPEED_MIN, VEGGIE_SPEED_MAX);
    this.type = random(['square', 'triangle', 'circle']);
  }
Line-by-line explanation (3 lines)
this.x = random(this.size / 2, width - this.size / 2);
Picks a new random horizontal starting position, keeping the whole veggie within the canvas width.
this.y = -this.size / 2;
Places the veggie just above the top edge of the canvas so it falls into view naturally.
this.type = random(['square', 'triangle', 'circle']);
p5's random() function, when given an array, returns one randomly chosen element - here picking a new shape type each time the veggie resets.

📦 Key Variables

gameState number

Tracks which of the four screens (START, PLAYING, GAMEOVER, WIN) the game is currently showing, driving the entire state machine in draw().

let gameState = START;
sonion object

Holds the single Sonion instance representing the player character, including its position and radius.

let sonion;
sonionSpeed number

Pixels per frame Sonion moves when a WASD/arrow key is held down.

let sonionSpeed = 5;
sonionLives number

Number of hits Sonion can take from rotten veggies before the game ends.

let sonionLives = 3;
SONION_RADIUS number

Constant defining Sonion's drawn size and collision radius.

const SONION_RADIUS = 30;
flavorPearls array

Stores all FlavorPearl objects currently in the game, collected or not.

let flavorPearls = [];
NUM_PEARLS number

Total number of pearls spawned each round and the score needed to win.

const NUM_PEARLS = 10;
PEARL_RADIUS number

Constant controlling the drawn size and collision radius of each pearl.

const PEARL_RADIUS = 10;
score number

Counts how many flavor pearls the player has collected so far.

let score = 0;
rottenVeggies array

Stores all RottenVegetable obstacle objects currently falling on screen.

let rottenVeggies = [];
NUM_VEGGIES number

How many rotten vegetables exist and fall simultaneously.

const NUM_VEGGIES = 5;
VEGGIE_SIZE number

Constant controlling the drawn size and collision radius of each vegetable.

const VEGGIE_SIZE = 40;
VEGGIE_SPEED_MIN number

Slowest possible falling speed randomly assigned to a vegetable.

const VEGGIE_SPEED_MIN = 2;
VEGGIE_SPEED_MAX number

Fastest possible falling speed randomly assigned to a vegetable.

const VEGGIE_SPEED_MAX = 5;
GAME_DURATION number

Total seconds allowed to collect all pearls before time runs out.

const GAME_DURATION = 30;
startTime number

Stores the millis() timestamp when the current round began, used to calculate elapsed time.

let startTime;
remainingTime number

Seconds left on the countdown clock, recalculated every frame and displayed in the HUD.

let remainingTime;
keyWPressed / keyAPressed / keySPressed / keyDPressed boolean

Track whether each movement key is currently held down, read every frame inside Sonion.move().

let keyWPressed = false;
touchTargetX / touchTargetY number

Stores the last tapped/dragged screen coordinate Sonion should move towards; -1 means no active touch target.

let touchTargetX = -1;
TOUCH_MOVE_SPEED number

Constant pixels-per-frame speed Sonion moves when chasing a touch target.

const TOUCH_MOVE_SPEED = 8;
TOUCH_THRESHOLD number

Minimum distance from the touch target before Sonion is considered to have 'arrived' and stops moving.

const TOUCH_THRESHOLD = 5;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG keyPressed() / keyReleased()

Movement flags are only updated when gameState === PLAYING, so if a player holds a movement key down before tapping to start (or while restarting), the flag never flips to true until the key is released and pressed again, making the character feel briefly unresponsive.

💡 Remove the gameState check from keyPressed()/keyReleased() (input handling doesn't hurt anything on other screens), or better, check keyIsDown('w') directly inside Sonion.move() instead of relying on flags set by event handlers.

PERFORMANCE RottenVegetable.draw()

Every single frame, this method calls random() up to 11 times to redraw the bumps and rot spots in new positions, which causes the vegetable's texture to visibly flicker/jitter rather than looking like a stable falling object.

💡 Generate the bump offsets once in the constructor (and again in reset()) and store them as instance properties, then reuse those fixed values every frame in draw() for a calmer, more solid-looking shape.

FEATURE windowResized()

Only Sonion's position is recentered when the window resizes; flavorPearls and rottenVeggies keep their old x/y coordinates from before the resize, so on a big size change they could end up clustered off-screen or oddly positioned.

💡 Store each pearl/veggie's position as a fraction of width/height (e.g. this.xRatio) instead of raw pixels, or simply call resetGame() inside windowResized() for a clean re-layout.

STYLE Sonion.draw() and RottenVegetable.draw()

Both drawing methods rely on many unexplained magic numbers (radius * 0.3, radius * 1.5, size * 0.2, etc.) scattered throughout, making it hard to tweak the character's proportions consistently.

💡 Extract repeated multipliers into named constants (e.g. const EYE_OFFSET = 0.3) at the top of the class so proportions can be tuned in one place and the code reads more clearly.

🔄 Code Flow

Code flow showing setup, resetgame, draw, updategame, drawgame, drawstartscreen, drawgameoverscreen, drawwinscreen, keypressed, keyreleased, touchstarted, touchmoved, touchended, windowresized, soniondraw, sonionmove, sonioncheckcollision, flavorpearldraw, rottenvegetabledraw, rottenvegetablemove, rottenvegetablereset

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> resetgame[resetGame] resetgame --> draw[draw loop] draw --> updategame[updateGame] draw --> drawgame[drawGame] updategame --> pearl-collection-loop[Pearl Collection Check] updategame --> veggie-collision-loop[Veggie Movement & Collision] updategame --> timer-calc[Countdown Timer] pearl-collection-loop --> pearl-draw-loop[Draw Every Pearl] veggie-collision-loop --> veggie-draw-loop[Draw Every Veggie] drawgame --> state-switch[Game State Router] state-switch --> drawstartscreen[drawStartScreen] state-switch --> drawgameoverscreen[drawGameOverScreen] state-switch --> drawwinscreen[drawWinScreen] click setup href "#fn-setup" click resetgame href "#fn-resetgame" click draw href "#fn-draw" click updategame href "#fn-updategame" click drawgame href "#fn-drawgame" click drawstartscreen href "#fn-drawstartscreen" click drawgameoverscreen href "#fn-drawgameoverscreen" click drawwinscreen href "#fn-drawwinscreen" click pearl-collection-loop href "#sub-pearl-collection-loop" click veggie-collision-loop href "#sub-veggie-collision-loop" click timer-calc href "#sub-timer-calc" click pearl-draw-loop href "#sub-pearl-draw-loop" click veggie-draw-loop href "#sub-veggie-draw-loop" click state-switch href "#sub-state-switch"

❓ Frequently Asked Questions

What does the p5.js sketch 'Very fun game but sonion th onion' create visually?

The sketch visually represents a cute, layered onion character named Sonion, which is animated to move around the screen. It features colorful graphics, including glowing flavor pearls that the onion collects and bouncing rotten vegetables that serve as obstacles.

How can users interact with the 'Very fun game but sonion th onion' sketch?

Users can interact with the sketch using keyboard controls (WASD keys) or touch controls to guide Sonion around the screen. The main objective is to collect flavor pearls while avoiding the bouncing rotten veggies within a set time limit.

What creative coding technique does the sketch demonstrate in p5.js?

The sketch demonstrates the use of collision detection and game state management, allowing for interactive gameplay elements like scoring and lives. It also showcases object-oriented programming through the Sonion class, which encapsulates the behavior and properties of the character.

How could someone recreate the glowing effect seen in the p5.js sketch?

To recreate the glowing effect, you can use a combination of transparent ellipses with varying opacities layered on top of each other. Adjust the fill colors and use the 'noStroke()' function for a smooth, glowing appearance, similar to how the flavor pearls are rendered in the sketch.

Preview

Very fun game but sonion th onion - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Very fun game but sonion th onion - Code flow showing setup, resetgame, draw, updategame, drawgame, drawstartscreen, drawgameoverscreen, drawwinscreen, keypressed, keyreleased, touchstarted, touchmoved, touchended, windowresized, soniondraw, sonionmove, sonioncheckcollision, flavorpearldraw, rottenvegetabledraw, rottenvegetablemove, rottenvegetablereset
Code Flow Diagram